跳到主要内容

使用一等函数实现设计模式

符合模式并不表示做得对。

——Ralph Johnson,经典的《设计模式:可复用面向对象软件的基础》的作者之一

有时,设计模式或 API 要求组件实现单方法接口,而那个方法的名称很宽泛,例如“execute”“run”或“doIt”。在 Python 中,这些模式或 API 通常可以使用一等函数或其他可调用的对象实现,从而减少样板代码。

案例分析:重构“策略”模式

定义:定义一系列算法,把它们一一封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。

策略模式描述了在一个需要根据上下文内容选择合适算法的问题中,如何更好的组织这些算法的设计模式。

策略模式有如下关键组件:

  • 上下文:提供进行算法选择的关键信息
  • 策略:不同算法的共用接口
  • 具体策略:策略的子类,不同算法的实现

经典的“策略”模式

假如一个网店制定了下述折扣规则。

  • 有 1000 或以上积分的顾客,每个订单享 5% 折扣。
  • 同一订单中,单个商品的数量达到 20 个或以上,享 10% 折扣。
  • 订单中的不同商品达到 10 个或以上,享 7% 折扣。

下图是使用“策略”设计模式处理订单折扣的 UML 类图

image-20220811140153050

在这个电商示例中,上下文Order,它会根据不同的算法计算促销折扣。

在这个示例中,名为 Promotion 的抽象类扮演策略这个角色

fidelityPromoBulkPromoLargeOrderPromo 是这里实现的三个具体策略

from abc import ABC, abstractmethod
from collections import namedtuple

Customer = namedtuple('Customer', 'name fidelity')


class LineItem:

def __init__(self, product, quantity, price):
self.product = product
self.quantity = quantity
self.price = price

def total(self):
return self.price * self.quantity


class Order: # 上下文

def __init__(self, customer, cart, promotion=None):
self.customer = customer
self.cart = list(cart)
self.promotion = promotion

def total(self):
if not hasattr(self, '__total'):
self.__total = sum(item.total() for item in self.cart)
return self.__total

def due(self):
if self.promotion is None:
discount = 0
else:
discount = self.promotion.discount(self)
return self.total() - discount

def __repr__(self):
fmt = '<Order total: {:.2f} due: {:.2f}>'
return fmt.format(self.total(), self.due())


class Promotion(ABC) : # 策略:抽象基类

@abstractmethod
def discount(self, order):
"""返回折扣金额(正值)"""


class FidelityPromo(Promotion): # 第一个具体策略
"""为积分为1000或以上的顾客提供5%折扣"""

def discount(self, order):
return order.total() * .05 if order.customer.fidelity >= 1000 else 0


class BulkItemPromo(Promotion): # 第二个具体策略
"""单个商品为20个或以上时提供10%折扣"""

def discount(self, order):
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * .1
return discount


class LargeOrderPromo(Promotion): # 第三个具体策略
"""订单中的不同商品达到10个或以上时提供7%折扣"""

def discount(self, order):
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * .07
return 0

演示

>>> joe = Customer('John Doe', 0) 
>>> ann = Customer('Ann Smith', 1100)
>>> cart = [LineItem('banana', 4, .5),
... LineItem('apple', 10, 1.5),
... LineItem('watermellon', 5, 5.0)]
>>> Order(joe, cart, FidelityPromo())
<Order total: 42.00 due: 42.00>
>>> Order(ann, cart, FidelityPromo())
<Order total: 42.00 due: 39.90>
>>> banana_cart = [LineItem('banana', 30, .5),
... LineItem('apple', 10, 1.5)]
>>> Order(joe, banana_cart, BulkItemPromo())
<Order total: 30.00 due: 28.50>
>>> long_order = [LineItem(str(item_code), 1, 1.0)
... for item_code in range(10)]
>>> Order(joe, long_order, LargeOrderPromo())
<Order total: 10.00 due: 9.30>
>>> Order(joe, cart, LargeOrderPromo())
<Order total: 42.00 due: 42.00>

这个示例完全可用,但是利用 Python 中作为对象的函数,可以使用更少的代码实现相同的功能。详情参见下一节。

使用函数实现“策略”模式

在上个示例中,每个具体策略都是一个类,而且都只定义了一个方法,即 discount。此外,策略实例没有状态(没有实例属性)。你可能会说,它们看起来像是普通的函数——的确如此。下面的示例是对之前示例的重构,把具体策略换成了简单的函数,而且去掉了 Promo 抽象类。

from collections import namedtuple

Customer = namedtuple('Customer', 'name fidelity')


class LineItem:

def __init__(self, product, quantity, price):
self.product = product
self.quantity = quantity
self.price = price

def total(self):
return self.price * self.quantity


class Order: # 上下文

def __init__(self, customer, cart, promotion=None):
self.customer = customer
self.cart = list(cart)
self.promotion = promotion

def total(self):
if not hasattr(self, '__total'):
self.__total = sum(item.total() for item in self.cart)
return self.__total

def due(self):
if self.promotion is None:
discount = 0
else:
discount = self.promotion(self)
return self.total() - discount

def __repr__(self):
fmt = '<Order total: {:.2f} due: {:.2f}>'
return fmt.format(self.total(), self.due())


def fidelity_promo(order):
"""为积分为1000或以上的顾客提供5%折扣"""
return order.total() * .05 if order.customer.fidelity >= 1000 else 0


def bulk_item_promo(order):
"""单个商品为20个或以上时提供10%折扣"""
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * .1
return discount

def large_order_promo(order):
"""订单中的不同商品达到10个或以上时提供7%折扣"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * .07
return 0

使用起来更简单,不需要实例化具体逻辑,只需要传入函数即可

Order(joe, cart, fidelity_promo) # 为了把折扣策略应用到 Order 实例上,只需把促销函数作为参数传入

值得注意的是,《设计模式:可复用面向对象软件的基础》一书的作者指出:“策略对象通常是很好的享元(flyweight)。” 那本书的另一部分对 “享元” 下了定义:“享元是可共享的对象,可以同时在多个上下文中使用。”

共享是推荐的做法,这样不必在每个新的上下文(这里是 Order 实例)中使用相同的策略时不断新建具体策略对象,从而减少消耗。

在复杂的情况下,需要具体策略维护内部状态时,可能需要把“策略”和“享元”模式结合起来。但是,具体策略一般没有内部状态,只是处理上下文中的数据。此时,一定要使用普通的函数,别去编写只有一个方法的类,再去实现另一个类声明的单函数接口。

函数比用户定义的类的实例轻量,而且无需使用“享元”模式,因为各个策略函数在 Python 编译模块时只会创建一次。普通的函数也是“可共享的对象,可以同时在多个上下文中使用”。

选择最佳策略:简单的方式

假设我们想创建一个“元策略”,让它为指定的订单选择最佳折扣。

promos = [fidelity_promo, bulk_item_promo, large_order_promo]  

def best_promo(order):
"""选择可用的最佳折扣
"""
return max(promo(order) for promo in promos)

promos 是函数列表。习惯函数是一等对象后,自然而然就会构建那种数据结构存储函数

虽然示例可用,而且易于阅读,但是有些重复可能会导致不易察觉的缺陷:若想添加新的促销策略,要定义相应的函数,还要记得把它添加到 promos 列表中;否则,当新促销函数显式地作为参数传给 Order 时,它是可用的,但是 best_promo 不会考虑它。

找出模块中的全部策略

在 Python 中,模块也是一等对象,而且标准库提供了几个处理模块的函数。

Python 文档是这样说明内置函数 globals 的:

返回一个字典,表示当前的全局符号表。这个符号表始终针对当前模块(对函数或方法来说,是指定义它们的模块,而不是调用它们的模块)。

下面的示例使用 globals 函数帮助 best_promo 自动找到其他可用的 *_promo 函数,过程有点曲折。

promos = [globals()[name] for name in globals()
if name.endswith('_promo')
and name != 'best_promo']

def best_promo(order):
"""选择可用的最佳折扣
"""
return max(promo(order) for promo in promos)

收集所有可用促销的另一种方法是,在一个单独的模块中保存所有策略函数,把 best_promo 排除在外

在下面的示例中,最大的变化是内省名为 promotions 的独立模块,构建策略函数列表。注意,示例要导入 promotions 模块,以及提供高阶内省函数的 inspect 模块(简单起见,这里没有给出导入语句,因为导入语句一般放在文件顶部)。

promos = [func for name, func in
inspect.getmembers(promotions, inspect.isfunction)]

def best_promo(order):
"""选择可用的最佳折扣
"""
return max(promo(order) for promo in promos)

inspect.getmembers 函数用于获取对象(这里是 promotions 模块)的属性,第二个参数是可选的判断条件(一个布尔值函数)。我们使用的是 inspect.isfunction,只获取模块中的函数。

不管怎么命名策略函数,示例 6-8 都可用;唯一重要的是,promotions 模块只能包含计算订单折扣的函数。当然,这是对代码的隐性假设。如果有人在 promotions 模块中使用不同的签名定义函数,那么 best_promo 函数尝试将其应用到订单上时会出错。

我们可以添加更为严格的测试,审查传给实例的参数,进一步过滤函数。这个示例的目的不是提供完善的方案,而是强调模块内省的一种用途。

动态收集促销折扣函数更为显式的一种方案是使用简单的装饰器,之后会讨论这种实现

命令模式

“命令”设计模式也可以通过把函数作为参数传递而简化

下面是菜单驱动的文本编辑器的 UML 类图,使用“命令”设计模式实现。各个命令可以有不同的接收者(实现操作的对象)。对 PasteCommand 来说,接收者是 Document。对 OpenCommand 来说,接收者是应用程序

image-20220811151245928

“命令”模式的目的是解耦调用操作的对象(调用者)和提供实现的对象(接收者)

这个模式的做法是,在二者之间放一个 Command 对象,让它实现只有一个方法(execute)的接口,调用接收者中的方法执行所需的操作。这样,调用者无需了解接收者的接口,而且不同的接收者可以适应不同的 Command 子类。调用者有一个具体的命令,通过调用 execute 方法执行。注意,图 6-2 中的 MacroCommand 可能保存一系列命令,它的 execute() 方法会在各个命令上调用相同的方法。

命令模式是回调机制的面向对象替代品。问题是,我们需要回调机制的面向对象替代品吗?有时确实需要,但并非始终需要。

我们可以不为调用者提供一个 Command 实例,而是给它一个函数。此时,调用者不用调用 command.execute(),直接调用 command() 即可。MacroCommand 可以实现成定义了 __call__ 方法的类。这样,MacroCommand 的实例就是可调用对象,各自维护着一个函数列表,供以后调用

class MacroCommand:
"""一个执行一组命令的命令"""

def __init__(self, commands):
self.commands = list(commands) # ➊

def __call__(self):
for command in self.commands: # ➋
command()

使用一等函数对“命令”模式的重新审视到此结束。站在一定高度上看,这里采用的方式与“策略”模式所用的类似:把实现单方法接口的类的实例替换成可调用对象。毕竟,每个 Python 可调用对象都实现了单方法接口,这个方法就是 __call__